使用者先儲值到自己的帳戶,跟著別人開的團下單,等團購截止結算時,系統從他的餘額把該付的金額扣掉。
帳號資料放在 users:
CREATE TABLE `users` (
`user_id` VARCHAR(20) NOT NULL,
`user_name` VARCHAR(20) NOT NULL,
`password` VARCHAR(256) NOT NULL,
`role` VARCHAR(10) NOT NULL DEFAULT 'employee',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
資料表transactions 就是這套系統的流水帳,儲值與扣款各佔一列,每一列都存下那次交易之後的結餘:
CREATE TABLE `transactions` (
`transaction_id` VARCHAR(36) NOT NULL COMMENT 'UUID',
`user_id` VARCHAR(20) NOT NULL,
`order_id` VARCHAR(36) DEFAULT NULL COMMENT '儲值時為 NULL',
`amount` INT NOT NULL COMMENT '交易金額 (正數)',
`closing_balance` INT NOT NULL COMMENT '交易後餘額',
`type` TINYINT NOT NULL COMMENT '1=DEBIT(扣款), 2=RECHARGE(儲值)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` VARCHAR(20) NOT NULL COMMENT '操作者',
PRIMARY KEY (`transaction_id`),
KEY `idx_user_created` (`user_id`, `created_at` DESC),
KEY `idx_order_id` (`order_id`),
CONSTRAINT `fk_transactions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
users 裡沒有餘額這一欄。想知道某人現在有多少錢,就得去流水帳撈他最新那一筆;訂單結算時每位參與者要查一次。
這個時候才發現——餘額應該直接放在 users 上 !
於是有了這次的 schema 變更,而它帶著一條規則:
新的
users.balance欄位,值不能從 0 開始。每個人的初始餘額,必須等於他在流水帳裡最新一筆的closing_balance。
寫出這句 SQL 不難:
ALTER TABLE `users` ADD COLUMN `balance` BIGINT NOT NULL DEFAULT 0;
難的是它只改了你本機那個資料庫。同事的機器、CI(跑自動化測試的那台)、下週重灌的本機,都還沒有這個欄位。
MyBatis-Plus負責把查詢結果映射成 Java 物件,至於表怎麼來、欄位怎麼改,它一概不管;pom.xml 裡也沒有 JPA,所以沒有「依 entity 自動建表」這回事,這也意味著schema 得有人負責。
把完整的建表語句存成一份 schema.sql 放進專案。要改表就連進資料庫執行,再回頭把檔案也改成一致,檔案就是資料庫的說明書,需要一個新環境時,拿著它從頭跑一次就好。
但這條規則寫不進 schema.sql
回頭看開頭那條規則:balance 的初始值必須等於流水帳的最新結餘,要新增這個欄位,除了 ALTER,還得跑一句 UPDATE 去回填資料。然而 UPDATE 放不進 schema.sql——那份檔案描述的是「表長什麼樣」,假使今天是多人同時開發,會發生下面的窘境:
| 你的開發環境 | schema.sql |
同事的開發環境 | |
|---|---|---|---|
balance 欄位 |
有 | 有 | 有 |
回填的 UPDATE |
手動跑過 | 放不進去 | 沒有人跑過 |
balance 的值 |
每個人的實際結餘 | — | 全部都是 0 |
三邊的表結構一模一樣,資料卻不一樣,而且沒有任何錯誤訊息。
schema.sql 只記錄了現在的表長什麼樣子,但 schema 演進(也就是遷移migration)真正要問的是"做過什麼?"
Flyway 是一套資料庫版本控制工具,把每一次變更寫成一支帶版本號的 SQL 檔,放進 src/main/resources/db/migration,發出去之後就不再改。(檔案命名規則是"版本號 + 雙底線 + 這次要做什麼")
V1__init_schema.sql 初始建表
V2__schema_updates.sql 加欄位、改型別,並回填既有資料
V3__fix_seed_passwords.sql 只改資料,一行結構都沒動
V4__add_categories.sql 新增一張表
V5__add_category_version.sql 既有的表加一個欄位
V6__add_rbac.sql 新增權限相關的表
V7__register_admin_rbac_resources.sql 灌一批權限資料
啟動時,Flyway 會去看一張它自己建的表 flyway_schema_history,裡面記著這個資料庫已經跑過哪幾支,然後只執行還沒跑過的。這時同事從git拉下來重啟,就會自動跟到同一版。
在 V2__schema_updates.sql 裡是這樣的:
ALTER TABLE `users` ADD COLUMN `balance` BIGINT NOT NULL DEFAULT 0 AFTER `role`;
-- 從每位使用者最新一筆流水帳的結餘回填
UPDATE `users` u
JOIN ( /* 取每人 created_at 最大的那筆 transactions */ ) latest
ON u.user_id = latest.user_id
SET u.balance = latest.closing_balance;
V2__schema_updates.sql 還把 orders.status 從 TINYINT 換成 VARCHAR,並把既有的數字值轉成字串,在改結構之後也順便把舊資料搬過去。
兩句話必須在同一支檔案裡,如果分開放,中間任何一步失敗,就會留下「欄位有了、值還沒回填」的資料庫。
Flyway 會幫每支檔案算一個 checksum(內容的指紋),寫進 flyway_schema_history。已執行過的檔案事後被改,指紋對不起來,啟動直接中止,Flyway 不會重跑已經執行過的版本。